Skip to content

fix(server)!: reject a wildcard bind with no advertised address - #3923

Merged
hubcio merged 33 commits into
apache:masterfrom
chengxilo:fix-unreachable-roster-candidates
Aug 31, 2026
Merged

hubcio merged 33 commits into
apache:masterfrom
chengxilo:fix-unreachable-roster-candidates

Conversation

@chengxilo

@chengxilo chengxilo commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR address?

Closes #3890

This PR block #3650, see #3890 for detail.

Rationale

A wildcard bind says which interfaces a node accepts on, not where a client reaches it, so publishing it as an address hands clients a target they cannot dial.

What changed?

node.advertised_address supplies it, and the server now refuses to start when server is configured with wildcard bind while leaves it unset.

Breaking:

deployments binding 0.0.0.0 without a roster must declare an address. The Helm chart can derives it from the Service DNS name and the shipped compose files name their service; anything else needs IGGY_NODE_ADVERTISED_ADDRESS. A cluster.nodes ip must now be a literal IP, and no declared address may be the unspecified one.

Some detail regarding new behavior:

When would it boot?

Standalone (cluster.enabled = false)

tcp.address node.advertised_address Boot Metadata publishes
127.0.0.1:8090 unset ✅ 127.0.0.1 (derived from the bind)
127.0.0.1:8090 broker.example.com ✅ broker.example.com (declared wins)
0.0.0.0:8090 unset ❌ rejected —
0.0.0.0:8090 broker.example.com ✅ broker.example.com
any 0.0.0.0 / :: ❌ rejected —
any broker:8090 (carries a port) ❌ rejected —

Cluster (cluster.enabled = true)

tcp.address only picks the bind interface here — ports come from the roster — so a wildcard is perfectly
normal.

Roster field Value Boot
nodes[].ip 172.28.0.101 ✅
nodes[].ip 0.0.0.0 / :: ❌ rejected
nodes[].ip iggy-server (any hostname) ❌ rejected, points at advertised_address
advertised_address unset ✅ → publishes ip
advertised_address broker.example.com ✅ → publishes it
advertised_address 0.0.0.0 / :: ❌ rejected
selector address 0.0.0.0 / :: ❌ rejected, error names the CIDR
node.advertised_address set ✅ but ignored, warns at startup

Which address a client is told (advertised_for):

selector matching the client's source IP (longest prefix) → advertised_address → ip

Both modes

tcp.address Boot
:8090 (empty host) ❌ rejected — Rust's SocketAddr has no such spelling
localhost:8090 (hostname) ❌ rejected — a literal IP is required
anything else that does not parse ❌ rejected, one message naming the fix

Edge cases

Situation Behavior
cluster.enabled = false with [[cluster.nodes]] left behind Roster is neither resolved nor validated —
boots even if an ip is a hostname
A selector whose CIDR does not parse ❌ rejected (previously dropped in silence)

That last row is a side fix: a malformed selector used to be swallowed, surfacing only as "clients on one network
get the catch-all address" with nothing to debug.

Local Execution

  • Passed
  • Pre-commit hooks ran

AI Usage

  1. Claude Code (Opus)
  2. Diagnosis and implementation
  3. Reviewed line by line with my best effort. BUT I am not very familiar with the server side code, so I am not sure if these changes introduce any side-effect that I didn't notice. According to the changed part, I think it looks ok.
  4. Yes

@github-actions

Copy link
Copy Markdown

Thanks for the PR. It is labeled S-waiting-on-review and queued for review.

Slash commands (own line, regular comment) move it around the queue:

  • /ready - back to S-waiting-on-review after addressing feedback
  • /author - flip to S-waiting-on-author while you finish changes
  • /request-review @user-or-team - request a reviewer

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Aug 19, 2026
Closes apache#3890

A server whose TCP listener binds a wildcard reported that wildcard as its own
client-facing address in GetClusterMetadata. SDKs collect roster addresses into
their reconnect candidates, so 0.0.0.0:8090 became a dial target that retry
would eventually pick and never reach.

A bind address answers which interfaces a node accepts on. It is not an answer
to where a client reaches it, and for the unspecified address the two have no
relation. With a roster each node already answers the second question through
cluster.nodes.advertised_address; without one the server had no way to be told
it at all. node.advertised_address supplies it, named to match its roster
counterpart, and the server now refuses to start when a wildcard bind leaves
the question unanswered. A concrete bind address still needs no declaration: it
already names an interface a client can reach.

Only what an operator declares is validated. A bind address is never held to
being routable, which is the mistake that made Kafka reject wildcard binds that
had always been valid (KAFKA-18281). The declared values are held to it in both
modes, so a roster ip, advertised address or per-network selector that names
the unspecified address stops the boot rather than the cluster: peers dialing
0.0.0.0 reach their own host, which is how a cluster comes up with every node
believing it is alone while its containers report healthy.

Resolution is now infallible past construction. A roster node is built through
TryFrom, so an address that does not parse fails there instead of leaving every
consumer to carry a fallback, and the fallbacks are gone: metadata no longer
publishes a raw unparsed string, forwarding no longer treats a missing replica
ip as "no target", and a selector whose CIDR does not parse is no longer
dropped in silence. The two listeners also resolve the self address once
between them, rather than each deriving it from its own bind address and
disagreeing whenever http.address and tcp.address differ.

BREAKING CHANGE: a server that binds a wildcard address without a roster now
refuses to start until node.advertised_address names where clients reach it.
Deployments that bind 0.0.0.0 must declare one: the Helm chart derives it from
the Service DNS name and the shipped compose files name their service, but an
external deployment needs the hostname or load balancer address its clients
dial. A cluster.nodes ip must now be a literal IP, and no declared address may
be the unspecified one.
@chengxilo
chengxilo force-pushed the fix-unreachable-roster-candidates branch from 60f1345 to 2b6fc1a Compare August 19, 2026 05:11
@codecov

codecov Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.84946% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.94%. Comparing base (a1df383) to head (c5aefe3).

Files with missing lines Patch % Lines
core/server/src/bootstrap.rs 96.38% 4 Missing and 2 partials ⚠️
core/configs/src/server_config/validators.rs 94.73% 2 Missing and 2 partials ⚠️
core/server/src/cluster_meta.rs 98.80% 0 Missing and 1 partial ⚠️
core/server/src/http.rs 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3923      +/-   ##
============================================
- Coverage     84.95%   84.94%   -0.02%     
  Complexity     1405     1405              
============================================
  Files          1224     1225       +1     
  Lines        179327   179727     +400     
  Branches     145615   146018     +403     
============================================
+ Hits         152347   152668     +321     
- Misses        22958    23009      +51     
- Partials       4022     4050      +28     
Components Coverage Δ
Rust Core 85.82% <97.84%> (-0.03%) ⬇️
Java SDK 67.35% <ø> (ø)
C# SDK 75.40% <ø> (+0.01%) ⬆️
Python SDK 90.06% <ø> (ø)
PHP SDK 85.65% <ø> (ø)
Node SDK 96.22% <ø> (+0.08%) ⬆️
Go SDK 69.36% <ø> (+0.06%) ⬆️
Files with missing lines Coverage Δ
core/configs/src/server_config/cluster.rs 99.09% <100.00%> (+0.39%) ⬆️
core/configs/src/server_config/defaults.rs 100.00% <100.00%> (ø)
core/configs/src/server_config/node.rs 100.00% <100.00%> (ø)
core/configs/src/server_config/server.rs 90.10% <100.00%> (+4.62%) ⬆️
core/server/src/dispatch.rs 89.54% <100.00%> (-0.16%) ⬇️
core/server/src/http/error.rs 86.96% <100.00%> (-3.13%) ⬇️
core/server/src/http/forward.rs 65.17% <100.00%> (-0.25%) ⬇️
core/server/src/cluster_meta.rs 99.06% <98.80%> (+0.46%) ⬆️
core/server/src/http.rs 92.96% <66.66%> (-0.36%) ⬇️
core/configs/src/server_config/validators.rs 92.44% <94.73%> (+0.36%) ⬆️
... and 1 more

... and 38 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mmodzelewski mmodzelewski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core logic is sound, there are a few things worth fixing before merge, though. Besides the comments in-line, here are the ones that don't have a direct reference in the changed code:

  • docker-compose.yml (root) — builds the Dockerfile (wildcard binds at Dockerfile:45-48), has no environment: block, and cluster is off by default, so the README-documented docker compose up quickstart now refuses boot. Needs IGGY_NODE_ADVERTISED_ADDRESS=localhost like the eight other compose files patched in this PR.
  • foreign/java/java-sdk/src/test/java/org/apache/iggy/client/BaseIntegrationTest.java:92 — the testcontainer sets IGGY_TCP_ADDRESS=0.0.0.0:8090 with no advertised var; it will refuse boot once apache/iggy:edge ships this change. The C# fixture got the fix (VsrCluster.cs:294); the Java one was missed. Dormant today only because CI takes the USE_EXTERNAL_SERVER path.
  • core/server/src/args.rs:57 — the --help example IGGY_TCP_ADDRESS=0.0.0.0:8090 is now boot-refusing when copy-pasted standalone; the READMEs were updated but the help text was missed.

Comment thread core/configs/src/server_config/server.rs
Comment thread core/server/src/bootstrap.rs Outdated
Comment thread core/configs/src/server_config/cluster.rs Outdated
Comment thread core/configs/src/server_config/cluster.rs
Comment thread core/server/src/cluster_meta.rs
Comment thread helm/charts/iggy/templates/deployment.yaml
@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Aug 23, 2026
@chengxilo
chengxilo force-pushed the fix-unreachable-roster-candidates branch 2 times, most recently from e581b2f to 850c340 Compare August 26, 2026 01:00
@chengxilo

Copy link
Copy Markdown
Contributor Author

/ready

@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Aug 26, 2026
@chengxilo
chengxilo requested a review from mmodzelewski August 26, 2026 01:14

@hubcio hubcio left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two things outside the diff:

  • .devcontainer/devcontainer.json still binds every listener to 0.0.0.0 with no IGGY_NODE_ADVERTISED_ADDRESS, so cargo run --bin iggy-server inside the devcontainer refuses to start. add "IGGY_NODE_ADVERTISED_ADDRESS": "localhost" there (ports are forwarded to localhost).
  • keeping the refusal for the bare image is the right call (a baked localhost would give go clients in other containers a self-dial reconnect candidate). the website quick start only passes -e IGGY_TCP_ADDRESS=0.0.0.0:8090 though, so it needs the extra -e in lock-step

optional, non-blocking:

  • ClusterConfig::validate now repeats every per-node check that TryFrom<ClusterNodeConfig> already does, with near-identical messages that have started to drift (1056 has the hint, 499 doesn't). calling ResolvedClusterNode::try_from(node.clone())? in the loop and building the conflict pool from the resolved node drops about 70 lines, and removes the node.ip.parse::<AdvertisedAddress>().ok() at 1150 that can't be None anymore.
  • parse-then-is_unspecified() appears 5 times; rejecting the wildcard inside FromStr for AdvertisedAddress (after to_canonical()) removes the method and all five branches, and stops ::ffff:10.0.0.1 and 10.0.0.1 being treated as two different addresses.
  • helm README says the server "refuses to start without it", but the chart's default image tag 0.7.0 predates the setting and just logs an unknown-variable warning.
  • [node] in config.toml sits between [http.tls] and [tcp], reads like part of the http block.
  • cluster_meta.rs:50 says a bracketed [2001:db8::1] gets bracketed twice by clients - true for the go and C# sdks, not the rust one, which just does format!("{}:{}").

Comment thread core/configs/src/server_config/validators.rs
Comment thread core/configs/src/server_config/cluster.rs Outdated
Comment thread core/integration/tests/server/cluster_metadata_advertised.rs Outdated
Comment thread core/configs/src/server_config/node.rs
Comment thread core/server/config.toml
Comment thread helm/charts/iggy/templates/deployment.yaml Outdated
Comment thread web/docker-compose.yml Outdated
Comment thread core/server/src/args.rs
@chengxilo

Copy link
Copy Markdown
Contributor Author

/ready

@chengxilo
chengxilo requested a review from hubcio August 27, 2026 23:28
@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Aug 27, 2026
@chengxilo

chengxilo commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

cluster_meta.rs:50 says a bracketed [2001:db8::1] gets bracketed twice by clients - true for the go and C# sdks, not the rust one, which just does format!("{}:{}").
It appears that only Go have the problem : (, so I only mention Go in the comment.. (it's because ipv6 with [] is consider as a incorrect input by standard library)

I'll update the iggy-website btw.

numinnex
numinnex previously approved these changes Aug 28, 2026
hubcio
hubcio previously approved these changes Aug 28, 2026
mmodzelewski
mmodzelewski previously approved these changes Aug 28, 2026
hubcio added 2 commits August 31, 2026 09:29
Merging master pulled in the partition forward path (apache#3987),
written against the pre-branch roster API: replica_ip() as an
Option, ClusterRoster.self_ip, and an infallible From for
ResolvedClusterNode. This branch validates roster IPs at config
time, so replica_ip() is plain and the conversion is a TryFrom.
Git merged the hunks without a textual conflict and left the
server crate uncompilable.

Adapt the forward path and its test helper to the resolved
roster. The unparsable-IP case in the roster walk test is gone:
such a node can no longer exist in a resolved roster.
@hubcio
hubcio dismissed stale reviews from mmodzelewski, numinnex, and themself via c5aefe3 August 31, 2026 08:15
@hubcio
hubcio merged commit 31b86ff into apache:master Aug 31, 2026
102 checks passed
@github-actions github-actions Bot removed the S-waiting-on-review PR is waiting on a reviewer label Aug 31, 2026
mmodzelewski added a commit that referenced this pull request Sep 4, 2026
The Pinot suite could not start the current apache/iggy:edge image:
the server now refuses a wildcard bind without an advertised address
(#3923) and the suite never set one. It also re-pulled the image on
every run, which made it far slower than the SDK suite, and its
readiness probe accepted any HTTP status below 500 from /. The SDK
suite in turn still claimed the published image shipped the legacy
server and only waited for the ports to open.

Both suites now configure the container the same way: advertise an
address clients can dial, let the server use every core, and gate on
/ping before running tests. The Pinot suite reuses the locally cached
image like the SDK suite does.
mmodzelewski added a commit that referenced this pull request Sep 4, 2026
)

The Pinot suite could not start the current apache/iggy:edge image:
the server now refuses a wildcard bind without an advertised address
(#3923) and the suite never set one. It also re-pulled the image on
every run, which made it far slower than the SDK suite, and its
readiness probe accepted any HTTP status below 500 from /. The SDK
suite in turn still claimed the published image shipped the legacy
server and only waited for the ports to open.

Both suites now configure the container the same way: advertise an
address clients can dial, let the server use every core, and gate on
/ping before running tests. The Pinot suite reuses the locally cached
image like the SDK suite does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server publishes its wildcard bind address as a client-facing address

5 participants